Documentation

inbox/Multi-Tenancy in Dynaplex.md

Multi-Tenancy in Dynaplex

Overview

Dynaplex implements row-level multi-tenancy using Entity Framework Core global query filters. This approach provides automatic tenant isolation at the database level, preventing data leakage between tenants while maintaining a single database with shared schema.

Architecture

Row-Level Multi-Tenancy

Each tenant-scoped entity includes a TenantId column (Guid) that identifies which tenant owns the data. EF Core global query filters automatically restrict queries to return only data for the current tenant.

┌─────────────────────────────────────────┐
│         PostgreSQL Database             │
│  ┌───────────────────────────────────┐  │
│  │  Schema: catalog                  │  │
│  │  ┌─────────────────────────────┐  │  │
│  │  │ items table                 │  │  │
│  │  ├─────────────────────────────┤  │  │
│  │  │ id         │ tenant_id      │  │  │
│  │  │ guid-1     │ tenant-a       │  │  │ ← Tenant A's data
│  │  │ guid-2     │ tenant-a       │  │  │
│  │  │ guid-3     │ tenant-b       │  │  │ ← Tenant B's data
│  │  │ guid-4     │ tenant-b       │  │  │
│  │  └─────────────────────────────┘  │  │
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

              ↓ EF Core Query Filters

┌──────────────────────┐    ┌──────────────────────┐
│   Tenant A Context   │    │   Tenant B Context   │
│  ───────────────────  │    │  ───────────────────  │
│  Returns only:       │    │  Returns only:       │
│  - guid-1            │    │  - guid-3            │
│  - guid-2            │    │  - guid-4            │
└──────────────────────┘    └──────────────────────┘

Comparison to Other Approaches

Approach Dynaplex Uses Pros Cons
Row-Level (TenantId column) ✅ Yes • Single database
• Easy to manage
• Cost-effective
• Simple backups
• All tenants share resources
• Requires careful query filtering
Schema per Tenant ❌ No • Better isolation
• Can customize per tenant
• Complex migration management
• Not supported well in EF Core
Database per Tenant ❌ No • Complete isolation
• Easy to backup/restore individual tenants
• High maintenance cost
• Difficult to scale

Note: Dynaplex uses schemas for component isolation (catalog, identity, spatial, etc.), NOT for tenant isolation.

Implementation Components

1. ICurrentTenantService

Provides access to the current tenant context. Must be implemented by the application.

// Interface (in Acsis.Dynaplex)
public interface ICurrentTenantService
{
    Guid TenantId { get; }
}

// Example implementation for multi-tenant production (with authentication)
public class CurrentTenantService : ICurrentTenantService
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public CurrentTenantService(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public Guid TenantId
    {
        get
        {
            // Get tenant ID from JWT claims
            var user = _httpContextAccessor.HttpContext?.User;
            var tenantClaim = user?.FindFirst("tenant_id")?.Value;

            if (string.IsNullOrEmpty(tenantClaim))
                throw new UnauthorizedAccessException("No tenant context available");

            return Guid.Parse(tenantClaim);
        }
    }
}

// Default implementation for single-tenant development (no authentication required)
// Available in Acsis.Dynaplex - see "Single-Tenant Development Mode" section below
public class DefaultTenantService : ICurrentTenantService
{
    // Auto-detects and returns the single tenant ID
    // Throws clear error if multiple tenants exist
}

2. ConfigureTenantFilters Extension

Automatically applies global query filters to all entities with a TenantId property.

// In DatabaseExtensions.cs
public static void ConfigureTenantFilters(this ModelBuilder modelBuilder, Guid tenantId)
{
    foreach (var entityType in modelBuilder.Model.GetEntityTypes())
    {
        var tenantIdProperty = entityType.FindProperty("TenantId");
        if (tenantIdProperty == null || tenantIdProperty.ClrType != typeof(Guid))
            continue;

        // Build filter: e => e.TenantId == tenantId
        var parameter = Expression.Parameter(entityType.ClrType, "e");
        var body = Expression.Equal(
            Expression.Property(parameter, "TenantId"),
            Expression.Constant(tenantId));
        var lambda = Expression.Lambda(body, parameter);

        entityType.SetQueryFilter(lambda);
    }
}

3. DbContext Configuration

Each DbContext injects ICurrentTenantService and configures tenant filters:

using Acsis.Dynaplex;
using Microsoft.EntityFrameworkCore;

public class CatalogDb : DbContext
{
    private readonly ICurrentTenantService? _tenantService;

    public CatalogDb(DbContextOptions<CatalogDb> options) : base(options) { }

    public CatalogDb(
        DbContextOptions<CatalogDb> options,
        ICurrentTenantService tenantService)
        : base(options)
    {
        _tenantService = tenantService;
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // ... other configurations ...

        // Add indexes for performance
        modelBuilder.Entity<Item>().HasIndex(i => i.TenantId);

        // Configure tenant filters (if service is available)
        if (_tenantService != null)
        {
            modelBuilder.ConfigureTenantFilters(_tenantService.TenantId);
        }

        // Schema isolation (component boundaries)
        modelBuilder.ConfigureSchemaIsolation(SCHEMA);
    }
}

Single-Tenant Development Mode

Overview

For development and single-tenant deployments, Dynaplex provides DefaultTenantService - an intelligent fallback implementation that automatically detects when only one tenant exists in the system.

Why Use DefaultTenantService?

Problem: During early development, you want tenant isolation infrastructure in place, but don't want to implement full authentication and tenant resolution yet.

Solution: DefaultTenantService provides zero-configuration tenant context when exactly one tenant exists, while failing fast with a clear error message when multiple tenants are added (signaling it's time to implement proper authentication).

How It Works

// In Acsis.Dynaplex/DefaultTenantService.cs
public class DefaultTenantService : ICurrentTenantService
{
    public Guid TenantId
    {
        get
        {
            // Queries database on first access
            // Caches result for request lifetime

            var tenants = dbContext.Tenants.Select(t => t.Id).ToList();

            return tenants.Count switch
            {
                0 => throw new InvalidOperationException(
                    "No tenants found. Initialize system with at least one tenant."),

                1 => tenants[0],  // ✅ Returns the single tenant ID

                _ => throw new InvalidOperationException(
                    $"Multiple tenants detected ({tenants.Count} tenants). " +
                    "Please implement proper ICurrentTenantService with authentication.")
            };
        }
    }
}

Registration Pattern

Use TryAddScoped so a real implementation can override:

// In Program.cs or service registration
services.TryAddScoped<ICurrentTenantService>(sp =>
{
    var factory = sp.GetRequiredService<IDbContextFactory<IdentityDb>>();
    return DefaultTenantService.Create(factory);
});

// Later, when adding real multi-tenant support, just register normally:
// This will take precedence over the TryAdd registration
services.AddScoped<ICurrentTenantService, HttpContextTenantService>();

Benefits

  1. Zero Configuration: Works automatically for single-tenant scenarios
  2. Fail Fast: Clear error when multiple tenants exist
  3. No Boilerplate: Don't need authentication setup during development
  4. Easy Migration: Just register a real implementation to override
  5. Tenant Filtering Active: All query filters work from day one

Development Workflow

┌─────────────────────────────────────────────┐
│  Stage 1: Single-Tenant Development         │
├─────────────────────────────────────────────┤
│  • DefaultTenantService registered          │
│  • One tenant in database                   │
│  • Auto-returns that tenant ID              │
│  • No authentication needed                 │
│  ✅ Tenant filtering ACTIVE                  │
└─────────────────────────────────────────────┘
              ↓
              ↓ (Add second tenant to database)
              ↓
┌─────────────────────────────────────────────┐
│  Stage 2: Transition Triggered              │
├─────────────────────────────────────────────┤
│  • DefaultTenantService throws exception    │
│  • Clear error: "Multiple tenants detected" │
│  • Forces you to implement authentication   │
│  ⚠️ Application won't start                  │
└─────────────────────────────────────────────┘
              ↓
              ↓ (Implement proper tenant service)
              ↓
┌─────────────────────────────────────────────┐
│  Stage 3: Multi-Tenant Production           │
├─────────────────────────────────────────────┤
│  • HttpContextTenantService registered      │
│  • Gets tenant ID from JWT claims           │
│  • Proper authentication required           │
│  ✅ Tenant filtering ACTIVE                  │
└─────────────────────────────────────────────┘

Example: Complete Setup

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// Register IdentityDb with DbContext factory for pooling
builder.Services.AddDbContextFactory<IdentityDb>(options =>
    options.UseNpgsql(connectionString));

// Register DefaultTenantService as fallback
// Uses TryAdd so it can be overridden by a real implementation
builder.Services.TryAddScoped<ICurrentTenantService>(sp =>
{
    var factory = sp.GetRequiredService<IDbContextFactory<IdentityDb>>();
    return DefaultTenantService.Create(factory);
});

// Register other DbContexts - they'll automatically get tenant service injected
builder.Services.AddDbContext<CatalogDb>((sp, options) =>
{
    options.UseNpgsql(catalogConnectionString);
    // Tenant service will be injected via constructor
});

var app = builder.Build();

// Seed database with initial tenant if needed
using (var scope = app.Services.CreateScope())
{
    var identityDb = scope.ServiceProvider.GetRequiredService<IdentityDb>();
    if (!identityDb.Tenants.Any())
    {
        identityDb.Tenants.Add(new Tenant
        {
            Id = Guid.NewGuid(),
            Name = "Default Tenant",
            Slug = "default"
        });
        identityDb.SaveChanges();
    }
}

app.Run();

Testing DefaultTenantService

[Fact]
public void DefaultTenantService_SingleTenant_ReturnsCorrectId()
{
    // Arrange
    var tenantId = Guid.NewGuid();
    var factory = CreateDbContextFactory(tenantId);
    var service = DefaultTenantService.Create(factory);

    // Act
    var result = service.TenantId;

    // Assert
    Assert.Equal(tenantId, result);
}

[Fact]
public void DefaultTenantService_MultipleTenants_ThrowsException()
{
    // Arrange
    var factory = CreateDbContextFactoryWithMultipleTenants();
    var service = DefaultTenantService.Create(factory);

    // Act & Assert
    var exception = Assert.Throws<InvalidOperationException>(() => service.TenantId);
    Assert.Contains("Multiple tenants detected", exception.Message);
}

[Fact]
public void DefaultTenantService_NoTenants_ThrowsException()
{
    // Arrange
    var factory = CreateDbContextFactoryWithNoTenants();
    var service = DefaultTenantService.Create(factory);

    // Act & Assert
    var exception = Assert.Throws<InvalidOperationException>(() => service.TenantId);
    Assert.Contains("No tenants found", exception.Message);
}

Migration to Multi-Tenant

When you're ready to support multiple tenants:

  1. Implement HttpContextTenantService:

    public class HttpContextTenantService : ICurrentTenantService
    {
        private readonly IHttpContextAccessor _httpContextAccessor;
    
        public HttpContextTenantService(IHttpContextAccessor httpContextAccessor)
        {
            _httpContextAccessor = httpContextAccessor;
        }
    
        public Guid TenantId
        {
            get
            {
                var tenantClaim = _httpContextAccessor.HttpContext?.User
                    ?.FindFirst("tenant_id")?.Value;
    
                if (string.IsNullOrEmpty(tenantClaim))
                    throw new UnauthorizedAccessException("No tenant context");
    
                return Guid.Parse(tenantClaim);
            }
        }
    }
    
  2. Register the new service:

    // This overrides the TryAddScoped registration
    services.AddScoped<ICurrentTenantService, HttpContextTenantService>();
    
  3. Add authentication:

    builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options => { /* configure JWT */ });
    
    app.UseAuthentication();
    app.UseAuthorization();
    
  4. Include tenant_id claim in JWTs:

    var claims = new[]
    {
        new Claim("tenant_id", user.TenantId.ToString()),
        new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
        // ... other claims
    };
    

When to Use DefaultTenantService

DO USE when:

  • Building a new application (greenfield)
  • Want tenant isolation from day one
  • Not ready to implement authentication yet
  • Deploying as single-tenant initially
  • Need a smooth path to multi-tenancy

DON'T USE when:

  • Already have multiple tenants
  • Authentication is already implemented
  • Running in production with multiple customers
  • Need tenant resolution from sources other than database (e.g., subdomain, header)

Security Benefits

Automatic Filtering

With global query filters, tenant isolation happens automatically:

// ✅ BEFORE: Manual filtering (error-prone)
var items = await catalogDb.Items
    .Where(i => i.TenantId == currentTenantId)  // Easy to forget!
    .ToListAsync();

// ✅ AFTER: Automatic filtering (safe)
var items = await catalogDb.Items.ToListAsync();  // Only current tenant's data

Protection Against Data Leakage

// Scenario: Developer forgets to filter by tenant

// ❌ WITHOUT global filters (DANGEROUS)
var allItems = await catalogDb.Items.ToListAsync();
// Returns ALL items from ALL tenants → DATA LEAK

// ✅ WITH global filters (SAFE)
var allItems = await catalogDb.Items.ToListAsync();
// Returns only current tenant's items → SAFE

Explicit Bypass for Admin Operations

// Admin scenario: Need to access all tenants' data
var allItems = await catalogDb.Items
    .IgnoreQueryFilters()  // Explicit bypass
    .ToListAsync();

Performance Considerations

Indexes on TenantId

CRITICAL: Every entity with TenantId must have an index for optimal query performance.

// In OnModelCreating
modelBuilder.Entity<Item>().HasIndex(i => i.TenantId);
modelBuilder.Entity<User>().HasIndex(u => u.TenantId);
// ... etc for all tenant-scoped entities

Without indexes, queries will perform table scans filtering by tenant, which is slow for large datasets.

Query Performance

-- With index on tenant_id (FAST)
SELECT * FROM catalog.items WHERE tenant_id = 'abc-123...';
-- Uses index scan → milliseconds

-- Without index (SLOW)
SELECT * FROM catalog.items WHERE tenant_id = 'abc-123...';
-- Uses sequential scan → seconds/minutes for large tables

Component Coverage

All Dynaplex components with tenant-scoped data have been updated:

Component Tenant-Scoped Entities Status
identity User, Group, Role, Permission, RefreshToken ✅ Configured
catalog Item ✅ Configured
spatial Movement (others are global) ✅ Configured
printing PrintJob, PrintJobData ✅ Configured
transport Shipment, Delivery, ShipmentItemAllocation ✅ Configured
workflow Workflow, WorkflowCategory, WorkflowEvent, WorkflowRun ✅ Configured
bbu Customer, DwellTime, others ✅ Configured

Note: Not all entities are tenant-scoped. For example:

  • Tenant table itself (in Identity) - not tenant-filtered
  • PlatformType, Country, Region - shared across tenants
  • Configuration tables - shared system-wide

Migration Impact

Adding Tenant Filters

Adding tenant filters is non-breaking and requires no database migration:

  • ✅ No schema changes
  • ✅ No new columns added
  • ✅ Only affects EF Core query generation
  • ✅ Existing migrations remain valid

Future Migrations

When creating new migrations after adding tenant filters, the filters will automatically apply to all queries, including migration validation queries.

Testing

Unit Testing

Mock ICurrentTenantService in tests:

[Fact]
public async Task GetItems_ReturnsOnlyCurrentTenantItems()
{
    // Arrange
    var tenantId = Guid.NewGuid();
    var tenantService = new Mock<ICurrentTenantService>();
    tenantService.Setup(x => x.TenantId).Returns(tenantId);

    var options = new DbContextOptionsBuilder<CatalogDb>()
        .UseInMemoryDatabase("test")
        .Options;

    using var context = new CatalogDb(options, tenantService.Object);

    // Add items for different tenants
    context.Items.Add(new Item { Id = Guid.NewGuid(), TenantId = tenantId });
    context.Items.Add(new Item { Id = Guid.NewGuid(), TenantId = Guid.NewGuid() });
    await context.SaveChangesAsync();

    // Act
    var items = await context.Items.ToListAsync();

    // Assert
    Assert.Single(items);  // Only returns current tenant's item
}

Integration Testing

Test with multiple tenant contexts:

[Fact]
public async Task TenantIsolation_PreventsCrossTenantAccess()
{
    var tenant1 = Guid.NewGuid();
    var tenant2 = Guid.NewGuid();

    // Create items for tenant 1
    using (var context = CreateContext(tenant1))
    {
        context.Items.Add(new Item { Id = Guid.NewGuid(), TenantId = tenant1 });
        await context.SaveChangesAsync();
    }

    // Try to access with tenant 2 context
    using (var context = CreateContext(tenant2))
    {
        var items = await context.Items.ToListAsync();
        Assert.Empty(items);  // Tenant 2 cannot see tenant 1's data
    }
}

Common Scenarios

Scenario 1: Background Jobs

Background jobs need special handling since there's no HTTP context:

public class ReportGenerationJob
{
    private readonly IDbContextFactory<CatalogDb> _contextFactory;

    public async Task GenerateReportForTenant(Guid tenantId)
    {
        // Create a scoped tenant service
        var tenantService = new ScopedTenantService(tenantId);

        // Create context with specific tenant
        using var context = new CatalogDb(
            _contextFactory.CreateDbContext().Database.GetDbConnection(),
            tenantService);

        var items = await context.Items.ToListAsync();
        // Generates report for specific tenant
    }
}

public class ScopedTenantService : ICurrentTenantService
{
    public ScopedTenantService(Guid tenantId) => TenantId = tenantId;
    public Guid TenantId { get; }
}

Scenario 2: System Migrations

Migrations and database initialization should use parameterless constructor:

// Migration design-time factory
public class CatalogDbFactory : IDesignTimeDbContextFactory<CatalogDb>
{
    public CatalogDb CreateDbContext(string[] args)
    {
        var builder = new DbContextOptionsBuilder<CatalogDb>();
        builder.UseNpgsql("connection-string");

        // Use parameterless constructor - no tenant filtering
        return new CatalogDb(builder.Options);
    }
}

Scenario 3: Admin Tools

Admin operations that need cross-tenant access:

public class AdminService
{
    private readonly CatalogDb _catalogDb;

    public async Task<List<Item>> GetAllItemsAcrossAllTenants()
    {
        // Explicitly bypass tenant filters for admin operation
        return await _catalogDb.Items
            .IgnoreQueryFilters()
            .ToListAsync();
    }

    public async Task<List<Item>> GetItemsForSpecificTenant(Guid tenantId)
    {
        // Filter manually when bypassing automatic filtering
        return await _catalogDb.Items
            .IgnoreQueryFilters()
            .Where(i => i.TenantId == tenantId)
            .ToListAsync();
    }
}

Troubleshooting

Issue: Queries return no data

Symptom: Queries return empty results even though data exists

Cause: ICurrentTenantService not injected or returns wrong tenant ID

Solution:

// Check if tenant service is registered in DI
services.AddScoped<ICurrentTenantService, CurrentTenantService>();

// Verify tenant ID in service
var tenantId = _tenantService.TenantId;
Console.WriteLine($"Current tenant: {tenantId}");

Issue: Queries return all tenants' data

Symptom: Queries return data from all tenants

Causes:

  1. DbContext created with parameterless constructor (no tenant service)
  2. Entity doesn't have TenantId property
  3. Tenant filters not configured in OnModelCreating

Solution:

// Always use constructor with tenant service at runtime
services.AddDbContext<CatalogDb>((sp, options) => {
    options.UseNpgsql(connectionString);
    // Ensure tenant service is injected
    var tenantService = sp.GetRequiredService<ICurrentTenantService>();
    return new CatalogDb(options, tenantService);
});

Issue: Slow query performance

Symptom: Queries are slower than expected

Cause: Missing index on TenantId column

Solution:

// Add index in OnModelCreating
modelBuilder.Entity<YourEntity>().HasIndex(e => e.TenantId);

// Or via migration
migrationBuilder.CreateIndex(
    name: "ix__your_table__tenant_id",
    schema: "your_schema",
    table: "your_table",
    column: "tenant_id");

Best Practices

✅ DO

  1. Always inject ICurrentTenantService in runtime contexts
  2. Add indexes on TenantId for all tenant-scoped entities
  3. Use IgnoreQueryFilters() explicitly when cross-tenant access is needed
  4. Document which entities are tenant-scoped in your domain model
  5. Test tenant isolation in integration tests
  6. Validate tenant ID before allowing operations

❌ DON'T

  1. Don't manually filter by TenantId - let global filters handle it
  2. Don't use ICurrentTenantService in migrations - use parameterless constructor
  3. Don't forget indexes - performance will suffer
  4. Don't bypass filters without explicit justification
  5. Don't share DbContext instances across tenant boundaries

Summary

Dynaplex's multi-tenancy implementation provides:

  • Automatic tenant isolation via EF Core global query filters
  • Protection against data leakage by default
  • Performance optimization through indexed queries
  • Flexibility for admin/system operations with explicit bypass
  • Simplicity - developers don't need to remember to filter every query

The implementation is secure by default while remaining flexible for edge cases.